<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8" />
    <title>实时地形点云</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <script type="importmap">
        {
          "imports": {
            "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
            "three/examples/jsm/": "https://unpkg.com/three@0.160.0/examples/jsm/"
          }
        }
    </script>
    <style>
        :root{--border:#e5e7eb;--muted:#6b7280;--ok:#0a7f3f;--err:#b91c1c;--bg:#f7f7f8;--panel:#ffffff}
        :root:not(.dev) .devOnly{display:none!important}
        *{box-sizing:border-box}
        html,body{height:100%}
        body{margin:0;background:var(--bg);font:14px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto;display:flex;flex-direction:column}
        .toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;background:var(--panel);border-bottom:1px solid var(--border)}
        .title{font-weight:700}
        .controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
        .btn{padding:6px 10px;border-radius:10px;border:1px solid var(--border);background:#fafafa;cursor:pointer}
        .btn:hover{background:#f2f2f2}
        label.inline{display:flex;gap:6px;align-items:center}
        select,input[type="range"],input[type="text"]{padding:4px 6px;border:1px solid var(--border);border-radius:8px;background:#fff}
        .stage{position:relative;flex:1 1 auto;background:#000}
        .wrap{max-width:1600px;margin:0 auto;width:100%}
        canvas.webgl{display:block;width:100%;height:auto;max-height:78vh;background:#000}
        .badges{position:absolute;left:10px;bottom:10px;display:flex;gap:8px;flex-wrap:wrap}
        .badge{font-size:12px;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,.85);border:1px solid var(--border)}
        .ok{color:var(--ok);background:rgba(236,253,245,.9);border-color:#a7f3d0}
        .err{color:var(--err);background:rgba(254,242,242,.9);border-color:#fecaca}
        .foot{padding:8px 16px;background:var(--panel);border-top:1px solid var(--border)}
        .muted{color:var(--muted);font-size:12px}
        details.log{padding:12px 16px;background:var(--panel);border-top:1px solid var(--border)}
        details.log>summary{cursor:pointer;font-weight:600}
        pre#logView{height:220px;overflow:auto;background:#0b1220;color:#d1e0ff;border-radius:12px;padding:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;margin-top:10px;white-space:pre-wrap;word-break:break-word}
        .pill{padding:2px 6px;border:1px solid var(--border);border-radius:999px;background:#fafafa}
    </style>
</head>
<body>

<header class="toolbar">
    <div class="title">实时地形点云</div>
    <div class="controls">
        <!-- 添加坐标轴开关 -->
        <label class="inline devOnly">
            <input type="checkbox" id="toggleAxes" checked />
            坐标轴
        </label>
        <label class="inline devOnly">点大小
            <input id="psize" type="range" min="0.6" max="5" step="0.1" value="5" />
        </label>
        <label class="inline devOnly">着色
            <select id="colorMode">
                <option value="height" selected>高度(Y)</option>
                <option value="frame">帧序</option>
                <option value="solid">纯色</option>
            </select>
        </label>
        <button class="btn" id="snapBtn">快照</button>
        <button class="btn" id="fitBtn">重置视角</button>
    </div>
</header>

<main class="stage">
    <div class="wrap">
        <canvas id="webgl" class="webgl"></canvas>
    </div>
    <div class="badges">
        <span class="badge" id="connBadge">正在连接 WS…</span>
        <span class="badge" id="fpsBadge">FPS --</span>
        <span class="badge" id="volBadge">体积 --</span>
        <span class="badge devOnly" id="ptBadge">点数 --</span>
        <span class="badge devOnly" id="gridBadge">网格: --</span>
    </div>
</main>

<div class="foot devOnly">
    <div class="muted">
        DTO：<code>data / points / type:"delta"{data:{r,c,z}}</code> · 约定：<code>Y = 高度（源 z）</code>
        <span id="metricState" style="margin-left:8px">等待数据…</span>
    </div>
</div>

<details class="log devOnly" open>
    <summary>诊断与日志</summary>
    <div class="controls" style="gap: 8px; margin: 8px 0;">
        <span class="pill">帧：<b id="dbg_frames">0</b></span>
        <span class="pill">字节：<b id="dbg_bytes">0</b></span>
        <span class="pill">最近帧大小：<b id="dbg_lastlen">0</b></span>
        <span class="pill">最近解析：<b id="dbg_lastshape">--</b></span>
        <span class="pill">错误：<b id="dbg_errs">0</b></span>
    </div>
    <div class="controls" style="gap: 8px; margin: 8px 0%;">
        <input id="wsInput" type="text" style="min-width: 360px" />
        <button class="btn" id="reconnectBtn">重新连接</button>
        <button class="btn" id="downloadLogBtn">下载日志</button>
        <button class="btn" id="downloadPayloadBtn">下载最近一帧</button>
        <button class="btn" id="clearLogBtn">清空日志</button>
    </div>
    <pre id="logView"></pre>
</details>

<script type="module">
    import * as THREE from "three";
    import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

    /* ========= 开关 ========= */
    const DEV_FORCE = false;
    const DEV_UI = DEV_FORCE || new URLSearchParams(location.search).get("dev")==="1";
    if (DEV_UI) document.documentElement.classList.add("dev");

    /* ========= 日志 ========= */
    const logView=document.getElementById("logView");
    let logBuffer="",logLines=0,errCount=0;
    const MAX_UI_LINES=2000;
    const ts=()=>{const d=new Date(),p=n=>String(n).padStart(2,"0");return`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3,"0")}`;};
    function pushLog(level,msg){ if(!DEV_UI) return;
        const line=`[${ts()}][${level}] ${msg}\n`; logBuffer+=line;
        if(++logLines>MAX_UI_LINES){const parts=logBuffer.split("\n"); logView.textContent=parts.slice(-MAX_UI_LINES).join("\n");}
        else{ logView.textContent+=line; }
        logView.scrollTop=logView.scrollHeight;
        if(level==="ERROR"){errCount++; document.getElementById("dbg_errs").textContent=String(errCount);}
    }
    const LOG={info:(...a)=>pushLog("INFO",a.join(" ")),warn:(...a)=>pushLog("WARN",a.join(" ")),error:(...a)=>pushLog("ERROR",a.join(" ")),debug:(...a)=>pushLog("DEBUG",a.join(" "))};
    let lastRawPayload="";
    const dlLogBtn=document.getElementById("downloadLogBtn");
    if (dlLogBtn){
        dlLogBtn.onclick=()=>{const blob=new Blob([logBuffer],{type:"text/plain;charset=utf-8"});const a=document.createElement("a");a.download=`pointcloud_client_${Date.now()}.log`;a.href=URL.createObjectURL(blob);a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);};
        document.getElementById("downloadPayloadBtn").onclick=()=>{if(!lastRawPayload){LOG.warn("当前还没有收到任何帧");return;}const blob=new Blob([lastRawPayload],{type:"application/json;charset=utf-8"});const a=document.createElement("a");a.download=`last_payload_${Date.now()}.json`;a.href=URL.createObjectURL(blob);a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);};
        document.getElementById("clearLogBtn").onclick=()=>{logBuffer="";logLines=0;logView.textContent="";errCount=0;document.getElementById("dbg_errs").textContent="0";};
    }

    /* ========= DOM ========= */
    const el=id=>document.getElementById(id);
    const $psize=el("psize"),$mode=el("colorMode"),$toggleAxes=el("toggleAxes");

    /* ========= Three ========= */
    const canvas=el("webgl");
    const renderer=new THREE.WebGLRenderer({canvas,antialias:true,preserveDrawingBuffer:true});
    renderer.setPixelRatio(devicePixelRatio);
    const scene=new THREE.Scene(); scene.background=new THREE.Color(0x000000);
    const camera=new THREE.PerspectiveCamera(60,1,0.1,5000);
    const controls=new OrbitControls(camera,renderer.domElement);
    controls.enableDamping=true; controls.dampingFactor=0.25; controls.maxPolarAngle=Math.PI/2;

    /* ========= 坐标轴 ========= */
    let axesHelper = null;
    let axesVisible = true;

    function createAxes() {
        // 创建坐标轴，长度设置为场景中合适的大小
        axesHelper = new THREE.AxesHelper(500);
        scene.add(axesHelper);
    }

    function toggleAxes(visible) {
        if (axesHelper) {
            axesHelper.visible = visible;
        }
        axesVisible = visible;
    }

    // 创建坐标轴
    createAxes();

    // 坐标轴开关事件监听
    if ($toggleAxes) {
        $toggleAxes.onchange = () => {
            toggleAxes($toggleAxes.checked);
        };
    }

    if ($psize) $psize.oninput = () => (mat.size = parseFloat($psize.value));

    /* ========= 网格（后端优先 + 锁） ========= */
    let grid={x0:0,x1:1,y0:0,y1:1,dx:1,dy:1};
    let specReady=false;
    let gridLocked=false;
    let lastSpecId=null;

    /* ========= 数据包围盒 ========= */
    const dataBounds={minX:Infinity,maxX:-Infinity,minZ:Infinity,maxZ:-Infinity,minY:Infinity,maxY:-Infinity};
    let hasBounds=false;
    function expandBoundsByPoint(p){
        if (p.x < dataBounds.minX) dataBounds.minX=p.x;
        if (p.x > dataBounds.maxX) dataBounds.maxX=p.x;
        if (p.y < dataBounds.minZ) dataBounds.minZ=p.y;
        if (p.y > dataBounds.maxZ) dataBounds.maxZ=p.y;
        if (p.z < dataBounds.minY) dataBounds.minY=p.z;
        if (p.z > dataBounds.maxY) dataBounds.maxY=p.z;
        hasBounds=isFinite(dataBounds.minX)&&isFinite(dataBounds.maxX);
    }
    function resetBounds(){ dataBounds.minX=dataBounds.minZ=dataBounds.minY=Infinity; dataBounds.maxX=dataBounds.maxZ=dataBounds.maxY=-Infinity; hasBounds=false; }

    /* ========= 视角 ========= */
    function makeViewController({camera,controls,getGrid,getBounds}) {
        const PRESETS={ front_low:{ pad:1.34, azimuthDeg:90, elevationDeg:55, refitGrow:1.03 } };
        const qs=new URLSearchParams(location.search);
        function withOverrides(p){ const pad=parseFloat(qs.get("pad")),az=parseFloat(qs.get("az")),el=parseFloat(qs.get("el"));
            return{pad:isFinite(pad)?pad:p.pad, azimuthDeg:isFinite(az)?az:p.azimuthDeg, elevationDeg:isFinite(el)?el:p.elevationDeg, refitGrow:(p.refitGrow??1.04)};}
        let preset=withOverrides(PRESETS.front_low); let lastFit={sx:0,sy:0,sz:0,source:"auto"};

        function currentBox(source="auto"){
            if ((source==="data"||source==="auto")&&getBounds()){const b=getBounds();return{minX:b.minX,maxX:b.maxX,minZ:b.minZ,maxZ:b.maxZ,minY:b.minY,maxY:b.maxY};}
            const g=getGrid(); const s=Math.max(1,Math.max(g.x1-g.x0,g.y1-g.y0))*0.02;
            return{minX:g.x0,maxX:g.x1,minZ:g.y0,maxZ:g.y1,minY:-s,maxY:s};
        }
        function fit(source="auto"){
            const box=currentBox(source);
            const sizeX=Math.max(1e-3,box.maxX-box.minX), sizeZ=Math.max(1e-3,box.maxZ-box.minZ), sizeY=Math.max(1e-3,box.maxY-box.minY);
            const cx=(box.minX+box.maxX)/2, cy=(box.minY+box.maxY)/2, cz=(box.minZ+box.maxZ)/2;
            const vFOV=THREE.MathUtils.degToRad(camera.fov), hFOV=2*Math.atan(Math.tan(vFOV/2)*(camera.aspect||1));
            const dist=Math.max(Math.sqrt(sizeX*sizeX+sizeZ*sizeZ)*0.5/Math.tan(hFOV/2), (sizeY*0.5)/Math.tan(vFOV/2))*(preset.pad||1.3);
            const az=THREE.MathUtils.degToRad(preset.azimuthDeg||90), el=THREE.MathUtils.degToRad(preset.elevationDeg||55);
            const dir=new THREE.Vector3(Math.cos(el)*Math.cos(az), Math.sin(el), Math.cos(el)*Math.sin(az)).normalize();
            const pos=new THREE.Vector3(cx,cy,cz).addScaledVector(dir,dist);
            camera.position.copy(pos); controls.target.set(cx,cy,cz);
            const radius=0.5*Math.sqrt(sizeX*sizeX+sizeY*sizeY+sizeZ*sizeZ);
            camera.near=Math.max(0.05, dist/200); camera.far=Math.max(3000, dist*14 + radius*2);
            camera.updateProjectionMatrix(); controls.update();
            lastFit={sx:sizeX,sy:sizeY,sz:sizeZ,source};
        }
        function maybeRefit(){ if(!getBounds())return; const b=getBounds(); const sx=b.maxX-b.minX,sy=b.maxY-b.minY,sz=b.maxZ-b.minZ; const g=preset.refitGrow||1.04;
            if (sx>lastFit.sx*g || sy>lastFit.sy*g || sz>lastFit.sz*g) fit(lastFit.source); }
        function resetPose(){ const g=getGrid(); const cx=(g.x0+g.x1)/2, cz=(g.y0+g.y1)/2; camera.position.set(cx+800,400,cz+800); controls.target.set(cx,0,cz); camera.updateProjectionMatrix(); controls.update(); }
        function refit(){ fit(lastFit.source); }
        return {fit,maybeRefit,resetPose,refit};
    }
    const view=makeViewController({camera,controls,getGrid:()=>grid,getBounds:()=>hasBounds?dataBounds:null});

    /* ========= 几何/材质 ========= */
    const MAX=1_000_000;
    const pos=new Float32Array(MAX*3), col=new Float32Array(MAX*3), frameIdx=new Float32Array(MAX);
    let write=0,count=0,frameNo=0;

    const geom=new THREE.BufferGeometry();
    geom.setAttribute("position",new THREE.BufferAttribute(pos,3));
    geom.setAttribute("color",new THREE.BufferAttribute(col,3));
    geom.setDrawRange(0,0);

    const mat=new THREE.PointsMaterial({size:5,vertexColors:true,sizeAttenuation:true});
    const cloud=new THREE.Points(geom,mat); cloud.frustumCulled=false; scene.add(cloud);
    if ($psize) $psize.oninput = () => (mat.size = parseFloat($psize.value));
    const modeVal = () => ($mode ? $mode.value : "height");

    /* ========= 指标 ========= */
    let volumeEMA=0,lastFrameTs=0; const VOL_ALPHA=0.25;
    function setMetrics({volume,points,fps}){
        if (typeof volume==="number"){
            if(!volumeEMA) volumeEMA=volume; volumeEMA=volumeEMA*(1-VOL_ALPHA)+volume*VOL_ALPHA;
            const vTxt=volumeEMA.toFixed(2)+" m³"; document.getElementById("volBadge").textContent="体积 "+vTxt;
        }
        if (typeof points==="number"){ const $ptb=document.getElementById("ptBadge"); $ptb && ($ptb.textContent="点数 "+count.toLocaleString()); }
        if (typeof fps==="number"){ document.getElementById("fpsBadge").textContent="FPS "+fps.toFixed(0); }
    }

    /* ========= Resize ========= */
    function resize(){
        const w=canvas.clientWidth || canvas.parentElement?.clientWidth || window.innerWidth;
        const h=Math.max(320, Math.min(window.innerHeight*0.78, canvas.parentElement?.clientHeight || window.innerHeight*0.78));
        renderer.setSize(w,h,false); camera.aspect=w/h; camera.updateProjectionMatrix(); view.refit();
    }
    resize(); addEventListener("resize",resize); requestAnimationFrame(resize);

    /* ========= 索引 ========= */
    const idxMap=new Map(); const revKey=new Array(MAX);
    const keyOf=(p)=> (p && p.c!=null && p.r!=null) ? `${p.c}|${p.r}` : `${Math.round(p.x*1e6)}|${Math.round(p.y*1e6)}`;
    function writeAt(i,p,fId){ pos[i*3]=p.x; pos[i*3+1]=p.z; pos[i*3+2]=p.y; frameIdx[i]=fId; expandBoundsByPoint(p); }

    /* ========= zRange（来自服务端） ========= */
    let zRange = {min: 0, max: 1};
    let lastRangeSeq = -1;

    function colorFromZ(z, zMin, zMax){
        const t=Math.max(0,Math.min(1,(z - zMin)/((zMax - zMin) || 1)));
        const c = new THREE.Color().setHSL((1 - t) * 0.66, 1.0, 0.5);
        return c;
    }
    function setColorAtIndex(i, p, fId){
        const m = modeVal();
        if (m === "solid") { col[i*3]=0.1; col[i*3+1]=0.26; col[i*3+2]=0.73; return; }
        if (m === "frame") { const hue=(fId*37)%360; const c=new THREE.Color().setHSL(hue/360,0.65,0.55); col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b; return; }
        const c = colorFromZ(p.z, zRange.min, zRange.max);
        col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b;
    }
    function recolorAll(){
        const m=modeVal(); if (count===0) return;
        if (m==="solid"){
            for(let i=0;i<count;i++){ col[i*3]=0.1; col[i*3+1]=0.26; col[i*3+2]=0.73; }
        } else if (m==="frame"){
            for(let i=0;i<count;i++){ const hue=(frameIdx[i]*37)%360; const c=new THREE.Color().setHSL(hue/360,0.65,0.55); col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b; }
        } else {
            for(let i=0;i<count;i++){ const z=pos[i*3+1]; const c=colorFromZ(z, zRange.min, zRange.max); col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b; }
        }
        geom.attributes.color.needsUpdate=true;
    }
    let requestRecolor=false; if ($mode) $mode.onchange=()=>requestRecolor=true;

    /* ========= 应用 DTO ========= */
    let fittedOnce=false;
    function applyDTO(dto){
        if(!dto) return;
        if (dto.full) reset();
        if (dto.gridSpec) updateGrid(dto.gridSpec, dto._source || 'unknown');

        if (typeof dto.zMin === "number" && typeof dto.zMax === "number") {
            zRange.min = dto.zMin; zRange.max = dto.zMax;
        }
        if (typeof dto.rangeSeq === "number" && dto.rangeSeq !== lastRangeSeq) {
            lastRangeSeq = dto.rangeSeq;
            recolorAll();
        }

        const arr=dto.data ?? dto.points ?? [];
        if (!Array.isArray(arr) || arr.length===0){
            if (typeof dto.volume==="number") setMetrics({volume:dto.volume,points:count});
            return;
        }

        const fId=++frameNo;
        const filtered = specReady ? arr.filter(p => p && p.x!=null && p.y!=null && p.x >= grid.x0 && p.x <  grid.x1 && p.y >= grid.y0 && p.y <  grid.y1) : arr;
        if (filtered.length===0){ if (typeof dto.volume==="number") setMetrics({volume:dto.volume,points:count}); return; }

        for (const p of filtered){
            const key=keyOf(p);
            let i;
            if (idxMap.has(key)){ i=idxMap.get(key); writeAt(i,p,fId); }
            else{
                i=write%MAX; const old=revKey[i]; if (old!==undefined) idxMap.delete(old);
                writeAt(i,p,fId); idxMap.set(key,i); revKey[i]=key; write++; if (count<MAX) count++;
            }
            setColorAtIndex(i, p, fId);
        }

        geom.attributes.position.needsUpdate=true;
        geom.attributes.color.needsUpdate=true;
        geom.setDrawRange(0,count);

        const now=performance.now(); if (lastFrameTs) setMetrics({fps:1000/(now-lastFrameTs)}); lastFrameTs=now;

        if (!fittedOnce && count>0){ fittedOnce=true; setTimeout(()=>view.fit("data"),30); } else { view.maybeRefit(); }

        if (typeof dto.volume==="number") setMetrics({volume:dto.volume,points:count});
        const $metricState=document.getElementById("metricState"); $metricState && ($metricState.textContent="运行中");
    }

    function updateGrid(spec, source='unknown'){
        if (!(spec && spec.x0!==undefined && spec.x1!==undefined && spec.y0!==undefined && spec.y1!==undefined)) return;
        if (gridLocked && source !== 'system') {
            LOG.info(`忽略 ${source} 的 gridSpec（已锁定）`);
            return;
        }
        grid={x0:spec.x0,x1:spec.x1,y0:spec.y0,y1:spec.y1,dx:spec.dx||5,dy:spec.dy||5};
        specReady=true; gridLocked=true;
        if (spec.specId) lastSpecId = spec.specId;
        const $grid=document.getElementById("gridBadge");
        if ($grid) $grid.textContent=`网格: ${grid.x0}-${grid.x1}, ${grid.y0}-${grid.y1}`;
        LOG.info(`网格边界(${source}): x[${grid.x0},${grid.x1}] y[${grid.y0},${grid.y1}]`);
    }

    function reset(){
        write=0; count=0; frameNo=0; volumeEMA=0; lastFrameTs=0; fittedOnce=false;
        geom.setDrawRange(0,0);
        idxMap.clear(); revKey.length=MAX;
        resetBounds();
        zRange = {min: 0, max: 1}; lastRangeSeq = -1;
        view.resetPose();
    }

    /* ========= WS ========= */
    const setConn=(ok,msg)=>{ const $conn=document.getElementById("connBadge"); $conn.textContent=ok?"WS 已连接":(msg||"正在连接 WS…"); $conn.className="badge "+(ok?"ok":"err"); };
    function defaultWs(){ const q=new URLSearchParams(location.search); const v=q.get("ws"); if(v) return v; const s=location.protocol==="https:"?"wss":"ws"; return `${s}://${location.host}/stream`; }
    let WS_URL=defaultWs(); const wsInput=document.getElementById("wsInput"); if (wsInput) wsInput.value=WS_URL;
    let ws=null,retryTimer=null,tried=0,hbTimer=null; let frames=0,bytes=0; let httpBootDone=false;

    function updateDbg(len,shape){ if(!DEV_UI) return;
        frames++; bytes+=(len||0); document.getElementById("dbg_frames").textContent=String(frames); document.getElementById("dbg_bytes").textContent=String(bytes);
        document.getElementById("dbg_lastlen").textContent=String(len||0); document.getElementById("dbg_lastshape").textContent=shape||"--"; }
    function heartbeat(){clearInterval(hbTimer);hbTimer=setInterval(()=>{try{if(ws&&ws.readyState===1) ws.send("ping");}catch(e){}},15000);}

    function connectWS(force=false){
        if (force&&ws){try{ws.close();}catch{}}
        clearTimeout(retryTimer); tried++;
        try{
            ws=new WebSocket(WS_URL); ws.binaryType="arraybuffer"; LOG.info("连接 WS：", WS_URL);
            ws.onopen=()=>{ setConn(true); tried=0; heartbeat(); LOG.info("WS 已连接"); };
            ws.onclose=(e)=>{ setConn(false,"WS 断开"); clearInterval(hbTimer); LOG.warn("WS 断开，code=",e.code," reason=",e.reason||""); retryTimer=setTimeout(()=>connectWS(), Math.min(6000,600*tried)); };
            ws.onerror=()=>{ setConn(false,"WS 错误"); LOG.error("WS 错误"); try{ ws.close(); }catch{} };
            ws.onmessage=(ev)=>{
                try{
                    if (typeof ev.data==="string"){
                        lastRawPayload=ev.data;
                        const s=ev.data.trim();
                        try{ const dto=JSON.parse(s); if(shouldIgnoreFull(dto)){updateDbg(s.length,"ignored(ws-full)");return;} const shape=routeDTO(dto, 'ws'); updateDbg(s.length,shape); return; }catch{}
                        const lines=s.split(/\r?\n/).filter(Boolean);
                        for (const line of lines){
                            try{ const dto=JSON.parse(line); if(shouldIgnoreFull(dto)){updateDbg(line.length,"ignored(ws-full)");continue;} const shape=routeDTO(dto, 'ws'); updateDbg(line.length,shape); }
                            catch(e){ LOG.error("JSON 解析失败：",e.message,"片段=",line.slice(0,200)); updateDbg(line.length,"text(parse-error)"); }
                        }
                    } else if (ev.data instanceof ArrayBuffer){
                        const pts=tryParseBinary(ev.data);
                        if (pts){ applyDTO({full:false,data:pts,_source:'ws'}); updateDbg(ev.data.byteLength,`binary(float32 xyz) pts=${pts.length}`); }
                        else { updateDbg(ev.data.byteLength,"binary(unknown)"); LOG.warn("未知二进制格式，长度=",ev.data.byteLength); }
                    } else { updateDbg(0, typeof ev.data); LOG.warn("未知帧类型：", typeof ev.data); }
                }catch(err){ LOG.error("onmessage 处理失败：",err.message); }
            };
        }catch(e){ setConn(false,"WS 打开失败"); LOG.error("WS 打开失败：", e.message); }
    }
    function shouldIgnoreFull(dto){ if (!httpBootDone) return false; if (Array.isArray(dto)) return true; if (dto && (Array.isArray(dto.data)||Array.isArray(dto.points)) && dto.full) return true; return false; }
    const reconn=document.getElementById("reconnectBtn"); if (reconn) reconn.onclick=()=>{ WS_URL=(document.getElementById("wsInput").value||"").trim(); connectWS(true); };
    connectWS();

    // 路由（打上来源）
    function routeDTO(dto, source='unknown'){
        if (Array.isArray(dto)){ applyDTO({full:true,data:dto,_source:source}); return "array(full)"; }
        if (dto && (Array.isArray(dto.data) || Array.isArray(dto.points))){
            if (dto.gridSpec) updateGrid(dto.gridSpec, source);
            applyDTO({full:!!dto.full,data:dto.data??dto.points,volume:dto.volume,zMin:dto.zMin,zMax:dto.zMax,rangeSeq:dto.rangeSeq,_source:source});
            return Array.isArray(dto.data)?"json(data[])":"json(points[])";
        }
        if (dto && dto.type==="delta" && dto.data && Array.isArray(dto.data.r)){
            const pts=cellsToPoints(grid,dto.data,true);
            applyDTO({full:!!dto.full,data:pts,volume:dto.volume,zMin:dto.zMin,zMax:dto.zMax,rangeSeq:dto.rangeSeq,_source:source});
            return "json(type=delta rc)";
        }
        if (dto && dto.gridSpec){ updateGrid(dto.gridSpec, source); return "json(gridSpec)"; }
        if (dto && dto.type==="points"){
            applyDTO({full:!!dto.full,data:dto.data??dto.points,volume:dto.volume,zMin:dto.zMin,zMax:dto.zMax,rangeSeq:dto.rangeSeq,_source:source});
            return "json(type=points)";
        }
        LOG.warn("未知 DTO：", JSON.stringify(dto).slice(0,200)); return "json(unknown)";
    }

    function tryParseBinary(buf){
        if (!buf || buf.byteLength%12!==0) return null;
        try{ const f32=new Float32Array(buf); const n=Math.floor(f32.length/3); const arr=new Array(n);
            for(let i=0;i<n;i++){ arr[i]={x:f32[i*3], y:f32[i*3+1], z:f32[i*3+2]}; } return arr;
        }catch(e){ LOG.error("二进制解析失败：",e.message); return null; }
    }

    /* ========= HTTP 首帧（等待后端网格/或 specId） ========= */
    function apiBase(){ const q=new URLSearchParams(location.search); const override=q.get("api"); if (override) return override.replace(/\/$/,""); if (location.origin && location.origin.startsWith("http")) return location.origin; return "http://127.0.0.1:8080"; }
    function buildBootstrapUrl(nextCursor){
        const base=apiBase()+"/api/grid/bootstrap"; const qs=new URLSearchParams(location.search); const p=new URLSearchParams();
        if (qs.get("specId")) p.set("specId", qs.get("specId"));
        else if (lastSpecId) p.set("specId", lastSpecId);
        else if (["x0","y0","rows","cols","dx","dy"].every(k=>qs.has(k))) ["x0","y0","rows","cols","dx","dy"].forEach(k=>p.set(k,qs.get(k)));
        p.set("pageSize","50000"); if (nextCursor) p.set("cursor",nextCursor);
        p.set("_",`${Date.now()}_${Math.random().toString(36).slice(2)}`); return `${base}?${p.toString()}`;
    }
    function specToGridSpec(spec){ return {x0:spec.x0,y0:spec.y0,x1:spec.x1 ?? (spec.x0+spec.cols*spec.dx), y1:spec.y1 ?? (spec.y0+spec.rows*spec.dy), dx:spec.dx, dy:spec.dy, specId:spec.specId}; }
    function cellsToPoints(spec,cells,attachRC=false){
        const pts=[]; if(!cells||!cells.r||!cells.c||!cells.z) return pts;
        const n=Math.min(cells.r.length,cells.c.length,cells.z.length);
        for(let i=0;i<n;i++){ const x=spec.x0+(cells.c[i]+0.5)*spec.dx; const y=spec.y0+(cells.r[i]+0.5)*spec.dy; const p={x,y,z:cells.z[i]}; if(attachRC){p.r=cells.r[i];p.c=cells.c[i];} pts.push(p); }
        return pts;
    }
    async function fetchBootstrapPaged(){
        try{
            // 等待最多 1.2s，看是否已通过 WS 收到网格/specId，以便 HTTP 与之对齐
            let waited=0; while(!lastSpecId && !gridLocked && waited<1200){ await new Promise(r=>setTimeout(r,100)); waited+=100; }

            let cursor=null, first=(count===0); let total=0;
            do{
                const url=buildBootstrapUrl(cursor);
                LOG.info("HTTP 首帧请求:", url);
                const res=await fetch(url,{cache:"no-store",headers:{"Accept":"application/json","Cache-Control":"no-cache"}});
                if(!res.ok){ LOG.warn("bootstrap 非 200：",res.status); throw new Error(`HTTP ${res.status}`); }
                const snap=await res.json();

                // 只在第一页更新 gridSpec
                if (first && snap.spec) updateGrid(specToGridSpec(snap.spec), 'http');

                const pts=cellsToPoints(snap.spec,snap.cells,true);
                if(pts.length>0){
                    applyDTO({full:first,data:pts,volume:snap.volume?.value,zMin:snap.zMin,zMax:snap.zMax,rangeSeq:snap.rangeSeq,_source:'http'});
                    if (first) setTimeout(()=>view.fit("data"),50);
                    first=false; total+=pts.length;
                }

                // 仅依赖服务端 nextCursor 进行翻页
                cursor = (typeof snap.nextCursor === 'string' && snap.nextCursor.length>0) ? snap.nextCursor : null;

                await new Promise(r=>setTimeout(r,0));
            } while(cursor);
            LOG.info(`HTTP 首帧完成：共导入 ${total.toLocaleString()} 个栅格点`);
            httpBootDone = true;
        }catch(e){ LOG.error("HTTP 首帧失败：", e.message); }
    }

    /* ========= 渲染 ========= */
    window.addEventListener("DOMContentLoaded", ()=>{ reset(); fetchBootstrapPaged(); });
    let tick=0,last=performance.now();
    function animate(){
        requestAnimationFrame(animate);
        controls.update();
        if (requestRecolor){ recolorAll(); requestRecolor=false; }
        renderer.render(scene,camera);
        tick++; const now=performance.now();
        if (now-last>=1000){ const fps=(tick*1000)/(now-last); document.getElementById("fpsBadge").textContent="FPS "+fps.toFixed(0); tick=0; last=now; }
    }
    animate();

    /* ========= 交互 ========= */
    document.getElementById("fitBtn").onclick=()=>view.fit("auto");
    document.getElementById("snapBtn").onclick=()=>{ const a=document.createElement("a"); a.download=`snapshot_${Date.now()}.png`; a.href=renderer.domElement.toDataURL("image/png"); a.click(); };
</script>
</body>
</html>